-
Notifications
You must be signed in to change notification settings - Fork 735
feat: Tab base directory with VS Code style redesign #2788
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
|
|
Caution Review failedThe pull request is closed. WalkthroughAdds optimistic-locking and lock-aware metadata update APIs in the Go backend (ErrVersionMismatch, ErrObjectLocked, UpdateObjectMetaWithVersion, UpdateObjectMetaIfNotLocked) and exposes corresponding RPC endpoints. Introduces server-side metadata validation and many new tab-related metadata keys. Frontend adds OSC 7 per-tab debouncing, path sanitization/validation utilities, atomic lock-aware updates, tab base-dir validation/handling, UI for setting/clearing/locking base dirs, tab status aggregation, presets support, shell-integration signals, and generated TS service stubs. Also adds docs, schemas, and UI/stylesheet adjustments. Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 18
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
frontend/app/store/keymodel.ts (1)
387-409: Prevent unhandled promise rejections from keybindings.
These handlers are async but are invoked without awaiting in the keymap. IfcreateBlockrejects, you’ll get unhandled rejections. Consider wrapping withfireAndForget(or internal try/catch) so the promise is consumed.🛠️ Proposed fix: fire-and-forget wrappers
-async function handleCmdN() { - const blockDef = await getDefaultNewBlockDef(); - await createBlock(blockDef); -} +function handleCmdN() { + fireAndForget(async () => { + const blockDef = await getDefaultNewBlockDef(); + await createBlock(blockDef); + }); +} -async function handleSplitHorizontal(position: "before" | "after") { +function handleSplitHorizontal(position: "before" | "after") { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { return; } - const blockDef = await getDefaultNewBlockDef(); - await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, position); + fireAndForget(async () => { + const blockDef = await getDefaultNewBlockDef(); + await createBlockSplitHorizontally(blockDef, focusedNode.data.blockId, position); + }); } -async function handleSplitVertical(position: "before" | "after") { +function handleSplitVertical(position: "before" | "after") { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { return; } - const blockDef = await getDefaultNewBlockDef(); - await createBlockSplitVertically(blockDef, focusedNode.data.blockId, position); + fireAndForget(async () => { + const blockDef = await getDefaultNewBlockDef(); + await createBlockSplitVertically(blockDef, focusedNode.data.blockId, position); + }); }
🤖 Fix all issues with AI agents
In `@CLAUDE.md`:
- Around line 330-342: The fenced ASCII-art code block in CLAUDE.md is missing a
language label which triggers markdownlint MD040; update the opening fence for
that ASCII-art block to include a language (e.g., add "text" after the triple
backticks) so the block becomes ```text, preserving the exact block content (the
TAB/Terminal/File View ASCII diagram) and the closing triple backticks remain
unchanged.
In `@emain/emain-ipc.ts`:
- Around line 469-471: The current assignment to allowedProperties uses
options.properties?.filter(...) || ["openDirectory"] which treats an empty
filtered array as truthy and bypasses the default; update the logic around
allowedProperties (in emain-ipc.ts) to treat an empty result as missing by
checking options.properties and/or the filtered array length and falling back to
["openDirectory"] when the input was undefined or the filter yields an empty
array (ensure you reference options.properties and the allowedProperties
variable and apply the filter only when there are items, otherwise set the
default).
In `@frontend/app/store/tab-basedir-validation-hook.ts`:
- Around line 13-58: validateActiveTabBasedir currently assumes
getTabModelByTabId returns a model and that validateTabBasedir never throws,
which can leave the atom stuck in "pending"; add a null guard after calling
getTabModelByTabId to return early if tabModel is falsy, and wrap the call to
validateTabBasedir in a try/catch; on any caught error set
globalStore.set(tabModel.basedirValidationAtom, "invalid") (or a designated
"error" state), update globalStore.set(tabModel.lastValidationTimeAtom, now) to
avoid debounce starvation, and optionally import/log the error before returning;
keep existing successful and stale-basedir paths (handleStaleBasedir) intact.
In `@frontend/app/store/tab-basedir-validator.ts`:
- Around line 88-92: The NFS-detection regex in the tab-basedir-validator (the
if that currently uses /^[^\/\\]+:\//) also matches Windows single-letter drives
like "C:/"; update that condition to exclude single-letter drive prefixes by
replacing the regex with a negative-lookahead form such as
/^(?![A-Za-z]:\/)[^\/\\]+:\// (keep the existing logical OR with
path.startsWith("/net/") and return true behavior in the same block).
In `@frontend/app/store/tab-model.ts`:
- Around line 85-87: setFinishedUnread currently only updates finishedUnreadAtom
and doesn't trigger terminalStatus recomputation; after calling
globalStore.set(this.finishedUnreadAtom, true) in setFinishedUnread(), invoke
this.recomputeTerminalStatus() so the terminalStatusAtom updates immediately to
reflect the finished state (ensure recomputeTerminalStatus is the same method
used elsewhere in this class to recalc terminalStatusAtom).
- Around line 77-79: clearFinishedUnread currently only sets finishedUnreadAtom
to false but doesn't trigger the terminal status recomputation; update
clearFinishedUnread to also call globalStore.set on the terminalStatusAtom (the
same way setFinishedUnread does) so the terminal status is recalculated — i.e.,
after setting this.finishedUnreadAtom to false, invoke
globalStore.set(this.terminalStatusAtom, ...) to trigger recompute of terminal
status.
In `@frontend/app/tab/tab.scss`:
- Around line 253-260: The stylesheet contains an unused `@keyframes` rule named
"spin" which is not referenced anywhere; remove the entire `@keyframes` spin { ...
} block from the SCSS (delete the "spin" keyframe definition) to eliminate dead
CSS and avoid unused animation definitions.
In `@frontend/app/view/term/term-model.ts`:
- Around line 460-472: In updateTabTerminalStatus, guard the call to
globalStore.get against an undefined shellIntegrationStatusAtom: before reading
this.termRef.current.shellIntegrationStatusAtom, verify that this.termRef and
this.termRef.current exist and that
this.termRef.current.shellIntegrationStatusAtom is not undefined/null; only then
call globalStore.get(...) to obtain shellIntegrationStatus, otherwise set
shellIntegrationStatus to null (or a safe default) and pass that into
this.tabModel.updateBlockTerminalStatus; update references to
globalStore.get(this.termRef.current.shellIntegrationStatusAtom) accordingly in
the updateTabTerminalStatus method.
In `@frontend/app/view/term/termwrap.ts`:
- Around line 269-277: The OSC 7 handler currently reads atoms.activeTab and
updates the wrong tab for background terminals; modify the handler signature
(handleOsc7Command) to accept a tabId parameter, update the call sites to pass
this.tabId from the TermWrap instance, and inside handleOsc7Command replace the
atoms.activeTab lookup with the passed tabId (use that id when creating tabORef
via WOS.makeORef("tab", tabId) and when selecting/updating the tab). Ensure
TermWrap invokes handleOsc7Command(this.tabId, ...) and update any related
references in the function to use the new parameter instead of atoms.activeTab.
In `@frontend/app/view/waveconfig/waveconfig-model.ts`:
- Around line 54-131: The validateTabVarsJson function currently allows
tab:basedir to be null and accepts non-finite numbers for display:order; update
validateTabVarsJson to match the schema by rejecting null for "tab:basedir"
(require a non-empty string and keep the existing length/null-byte/traversal
checks) and tighten "display:order" validation to require a number that is
finite (use Number.isFinite check) so NaN/Infinity are rejected; update the
error messages returned from the branches for presetKey "tab:basedir" and
"display:order" to reflect the stricter requirements and reference the preset
key in the messages (function: validateTabVarsJson; keys: "tab:basedir",
"display:order", presetKey loop).
In `@frontend/app/workspace/widgets.tsx`:
- Around line 34-75: The code mutates the shared widget.blockdef (blockDef.meta)
inside handleWidgetSelect which can leak tab-specific state; clone the
widget.blockdef and its meta before applying tabBaseDir overrides so mutations
only affect the instance being launched. Specifically, create a shallow/deep
copy of widget.blockdef (and blockDef.meta) at the start of handleWidgetSelect,
then apply the validateTabBasedir logic and set cmd:cwd or file on the cloned
blockDef.meta (not widget.blockdef), and use the cloned blockDef for the rest of
the widget launch flow.
In `@frontend/app/workspace/workspace.tsx`:
- Around line 74-77: The menu trigger is a non-focusable div; replace the <div
className="menu-button" onClick={handleMenuClick} title="Menu"> with a semantic
<button type="button" className="menu-button" onClick={handleMenuClick}
aria-label="Menu"> to restore keyboard focus and activation (keep the inner <i
className="fa fa-ellipsis" /> and the title if desired). Ensure any CSS
targeting .menu-button works for a button and remove any custom keyboard
handlers since button provides native keyboard support.
In `@frontend/util/presetutil.ts`:
- Around line 19-37: The current PRESET_KEY_ALLOWLISTS contains "bg:*" but the
validation uses exact Set.has checks so wildcard keys like "bg:borderradius"
will fail; implement wildcard support by adding a helper isKeyAllowed(key:
string, allowed: Set<string>) that returns true if allowed.has(key) OR if any
entry in allowed endsWith('*') and the key startsWith(thatEntry.slice(0, -1));
then update all validation sites that call allowedKeys.has(key) (replace with
isKeyAllowed(key, allowedKeys)) so "bg:*" acts as a prefix wildcard;
alternatively if you intend "bg:*" to be literal, remove the wildcard and list
explicit bg: keys or document the convention.
In `@pkg/service/objectservice/objectservice.go`:
- Around line 184-197: UpdateObjectMetaIfNotLocked currently skips metadata
validation and directly calls wstore.UpdateObjectMetaIfNotLocked; before calling
the store, validate the incoming meta (waveobj.MetaMapType) the same way other
update paths do—e.g., call the existing validation helper (or add one named
validateMeta(meta waveobj.MetaMapType) if missing) and return an error on
validation failure; keep the rest of the function (parseORef, context setup, and
call to wstore.UpdateObjectMetaIfNotLocked) unchanged so only valid metadata
reaches the store.
- Around line 163-176: UpdateObjectMetaWithVersion is missing the metadata
validation that UpdateObjectMeta performs, so add the same validation step
before persisting: invoke the same validation function or logic used in
UpdateObjectMeta (e.g., the meta validation helper such as validateMeta or
waveobj.ValidateMeta used earlier) on the meta parameter and return an error if
validation fails, then proceed to call wstore.UpdateObjectMetaWithVersion;
ensure you reference UpdateObjectMetaWithVersion and the validation helper so
the behavior matches the non-versioned path.
In `@pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh`:
- Around line 99-118: The captured $existingHandler is never used, which
overwrites any user Enter key customization; update the PSReadLine wrapper
installed via Set-PSReadLineKeyHandler to preserve prior behavior: detect if
$existingHandler (from Get-PSReadLineKeyHandler -Chord Enter) has a .ScriptBlock
and invoke it (passing through the same buffer/cursor context) inside the new
ScriptBlock either before or after calling
[Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine(), or if you intended to
discard prior handlers remove the unused $existingHandler variable; modify the
wrapper around _waveterm_si_send_command so it calls the original handler's
ScriptBlock when present to retain user customizations.
In `@pkg/waveobj/validators.go`:
- Around line 205-215: The UNC path check currently cleans the path before
testing for traversal which removes ".." and defeats detection; update the logic
in validators.go (the Windows UNC branch that uses runtime.GOOS,
strings.HasPrefix, filepath.Clean, parts and sharePath) to inspect the raw,
uncleaned path segments first (split the original path on filepath.Separator or
backslashes), detect any ".." segments that would escape the "\\server\share"
root, and only then proceed to clean/normalize; specifically, build the UNC root
from the first four raw segments and fail if any ".." would reduce the segment
count below the share root or if any segment before the share root is "..",
ensuring the check runs before calling filepath.Clean or relying on
strings.HasPrefix.
In `@schema/tabvarspresets.json`:
- Around line 1-39: The schema rejects null and empty string for the
"tab:basedir" property while the runtime validator accepts them; update the
TabVarPreset definition so "tab:basedir" accepts null and empty strings by
changing its type from "string" to ["string","null"] and relaxing the pattern to
allow an empty value (e.g., replace the "^[^\\x00]+$" pattern with one that
permits zero-length like "^[^\\x00]*$" or remove the pattern), keeping maxLength
and the description intact so schema and runtime behavior align.
🧹 Nitpick comments (18)
frontend/app/tab/workspaceswitcher.scss (1)
15-20: Consider removing!importantif possible.The
!importantdeclarations work but can make future style overrides difficult. If these are needed to override styles from a parent component or library, consider increasing selector specificity instead (e.g.,.tab-bar-wrapper .workspace-switcher-button). Otherwise, the transparent background with subtle hover effect nicely matches the VS Code aesthetic.frontend/app/tab/tab-menu.ts (1)
129-136:onApplycallback executes even ifUpdateObjectMetafails.The
onApplycallback runs afterUpdateObjectMetaregardless of whether the update succeeded. If the update throws,fireAndForgetswallows the error, butonApplywon't execute since the exception aborts the async function. However, if you want explicit error handling or user feedback on failure, consider wrapping with try/catch.💡 Optional: Add error handling for better UX
click: () => fireAndForget(async () => { - // Sanitize preset to ensure only allowed keys are sent - const sanitizedPreset = sanitizePreset(presetName, preset); - await ObjectService.UpdateObjectMeta(oref, sanitizedPreset); - onApply?.(presetName); + try { + const sanitizedPreset = sanitizePreset(presetName, preset); + await ObjectService.UpdateObjectMeta(oref, sanitizedPreset); + onApply?.(presetName); + } catch (e) { + console.error(`[Preset] Failed to apply "${presetName}":`, e); + } }),pkg/waveobj/validators.go (3)
145-162: Path existence check has TOCTOU implications.The
os.Statcall on lines 146-162 creates a time-of-check-time-of-use window. Between validation and actual use of the path, the file system state could change. Since this is soft validation (warn but allow), the impact is limited, but be aware that:
- Non-existent paths at validation time may exist later (acceptable per comment)
- Existing paths at validation time may not exist when used
This appears intentional given the logging approach, but consider documenting this behavior in the function's comment.
296-322: Integer validation silently truncates fractional values.
ValidateIntcastsfloat64tointat line 312 without verifying the value is a whole number. A value like5.9passes validation as5, which may be unexpected.♻️ Suggested improvement
func ValidateInt(key string, value interface{}, minVal, maxVal int) error { if value == nil { return nil } // JSON numbers come as float64 f, ok := value.(float64) if !ok { return &ValidationError{ Key: key, Value: value, Message: fmt.Sprintf("must be a number, got %T", value), } } i := int(f) + if float64(i) != f { + return &ValidationError{ + Key: key, + Value: f, + Message: "must be an integer, got floating point value", + } + } if i < minVal || i > maxVal {
853-872: Consider adding scope validation for AI presets.
PresetKeyScopedefines allowed keys fortabvar@andbg@prefixes, butai@presets are actively used (referenced inCountCustomAIPresetsin settingsconfig.go) and have well-defined keys (MetaKey_AiBaseURL, MetaKey_AiName, MetaKey_AiModel, etc.). Currently, ai@ presets bypass validation with only a warning. Adding them toPresetKeyScopewould enforce consistency with other preset types.pkg/wconfig/defaultconfig/presets/tabvars.json (1)
1-10: Consider clarifying these are example presets.These presets reference specific paths (
~/Code/waveterm,~/Projects/myapp) that likely won't exist for most users. Since the path validation allows non-existent paths with warnings, this won't cause errors, but users may be confused by seeing these defaults.Consider either:
- Removing these defaults so users start with an empty preset file
- Adding a comment (if JSON comments are supported) or documentation explaining these are examples
- Renaming to clearly indicate they're examples (e.g.,
tabvar@example-waveterm-dev)frontend/app/store/keymodel.ts (1)
335-362: Consider validating tab basedir only when you actually need it.
Right now validation runs before checking the focused block’scmd:cwd, which can delay shortcuts unnecessarily (especially for network paths). You could first attempt the focused block cwd, and only validate tab basedir if it’s still needed.Also applies to: 379-382
pkg/wstore/wstore.go (2)
62-65: Silently ignored error from DBGetORef.The error returned by
DBGetORefis discarded. While this follows the existing pattern inUpdateObjectMeta(line 39), database errors could be silently masked. Consider logging or returning the error if non-nil.♻️ Suggested improvement
- obj, _ := DBGetORef(tx.Context(), oref) + obj, err := DBGetORef(tx.Context(), oref) + if err != nil { + return fmt.Errorf("error fetching object: %w", err) + } if obj == nil { return ErrNotFound }
104-108: Consider clearer error wrapping order.The error wrapping
fmt.Errorf("%w: %w", ErrVersionMismatch, ErrObjectLocked)putsErrVersionMismatchas the primary error. Since the actual cause is a lock conflict, callers usingerrors.Is(err, ErrObjectLocked)will still work, but semanticallyErrObjectLockedis the specific cause. Consider swapping the order or using a single sentinel error.♻️ Alternative wrapping
- return fmt.Errorf("%w: %w", ErrVersionMismatch, ErrObjectLocked) + return fmt.Errorf("%w: %w", ErrObjectLocked, ErrVersionMismatch)frontend/util/pathutil.ts (3)
152-155: Control character regex is intentional - acknowledge static analysis.The regex
/[\x00-\x1F]/intentionally detects control characters (null byte through unit separator) which is correct for security validation. The Biome linter warning is a false positive in this context.Consider adding a comment to document this intentional usage:
📝 Document intentional usage
// Control characters (0x00-0x1F) + // Note: Intentionally checking for control characters - linter warning is expected if (/[\x00-\x1F]/.test(path)) { return true; }
125-129: Potential false positive for//paths on Unix.The pattern
/^\/\/[^\/]/flags paths starting with//followed by a non-slash, which could match legitimate Unix paths. While//foois technically valid on some Unix systems (and often normalized to/foo), this is a reasonable security tradeoff for blocking URL-style UNC paths.Consider limiting this check to Windows only if false positives become an issue:
// URL-style UNC that might slip through: //server/share // (if it starts with // followed by non-slash) - if (/^\/\/[^\/]/.test(path)) { + if (PLATFORM === PlatformWindows && /^\/\/[^\/]/.test(path)) { return true; }
48-72: Consider adding higher-numbered COM/LPT ports.Windows supports COM10-COM256 and LPT10-LPT256 as device names in some contexts. The current list covers the most common cases, but a more robust approach could use a regex pattern.
♻️ Alternative using regex in isWindowsDeviceName
-const WINDOWS_DEVICE_NAMES = [ - "CON", - "PRN", - "AUX", - "NUL", - "COM1", - // ... etc -]; +const WINDOWS_DEVICE_NAME_PATTERN = /^(CON|PRN|AUX|NUL|COM\d+|LPT\d+)$/i; function isWindowsDeviceName(path: string): boolean { const parts = path.split(/[\/\\]/); const filename = parts[parts.length - 1] || path; const nameWithoutExt = filename.split(".")[0].toUpperCase(); - return WINDOWS_DEVICE_NAMES.includes(nameWithoutExt); + return WINDOWS_DEVICE_NAME_PATTERN.test(nameWithoutExt); }frontend/app/tab/tab.tsx (1)
81-89: Consider adding error handling for the auto-clear update.The
ObjectService.UpdateObjectMetacall inside the timeout lacks error handling. While unlikely to fail, unhandled promise rejections could be silently swallowed.♻️ Add error handling
const timer = setTimeout(() => { - ObjectService.UpdateObjectMeta(makeORef("tab", id), { - "tab:termstatus": null, - }); + ObjectService.UpdateObjectMeta(makeORef("tab", id), { + "tab:termstatus": null, + }).catch((err) => console.warn("Failed to clear tab status:", err)); }, delay);frontend/app/view/term/termwrap.ts (2)
316-319: Dynamic import inside hot path may cause timing inconsistencies.The dynamic
import("@/store/tab-model")inside the debounced callback executes on every successful OSC 7 update. While this works, it adds latency and the import might resolve at different times.Consider importing
getTabModelByTabIdstatically at the top of the file sincetab-modelis likely already bundled with the frontend.Suggested change
Add at top of file:
import { getTabModelByTabId } from "@/store/tab-model";Then replace lines 316-319:
- const { getTabModelByTabId } = await import("@/store/tab-model"); - const tabModel = getTabModelByTabId(tabId); + const tabModel = getTabModelByTabId(tabId);
188-191: Consider removing or reducing verbose console.log in production.Lines 188 and 190 log every OSC 7 event. While useful for debugging, this may generate excessive console noise in production.
Suggested change
- console.log("OSC 7 received:", { data, blockId, loaded }); + dlog("OSC 7 received:", { data, blockId, loaded }); if (!loaded) { - console.log("OSC 7 ignored - terminal not loaded"); + dlog("OSC 7 ignored - terminal not loaded"); return true; }frontend/app/store/tab-basedir-validator.ts (2)
171-189: UnusedtabIdparameter invalidateTabBasedir.The
tabIdparameter is declared but never used in the function body. It appears to be vestigial from an earlier design.Suggested fix
export async function validateTabBasedir( - tabId: string, basedir: string ): Promise<PathValidationResult> {Note: This will require updating all call sites. Alternatively, prefix with underscore to indicate intentionally unused:
export async function validateTabBasedir( - tabId: string, + _tabId: string, basedir: string ): Promise<PathValidationResult> {
289-295: Module-level mutable state may cause issues with concurrent validation flows.The
batchingStatesingleton is shared across all validation calls. If the module is imported multiple times (e.g., in tests or hot-reload scenarios), each import shares the same state, which could lead to unexpected behavior.For production use this is likely fine, but consider documenting this singleton behavior.
frontend/app/store/tab-model.ts (1)
39-44: Add lifecycle cleanup to prevent TabModel cache accumulation in long sessions.The
tabModelCacheglobalMapaccumulatesTabModelinstances without any removal mechanism. When tabs are closed viaremoveTabView(), theTabModeland itsterminalStatusMapremain cached indefinitely. While individual blocks are cleaned up viaremoveBlockTerminalStatus(), the entireTabModelinstance is never disposed. In long sessions with many tab open/close cycles, this leads to unbounded memory growth.Consider adding a cache eviction mechanism or explicit cleanup (e.g.,
tabModelCache.delete(tabId)) triggered when tabs are removed.
| ``` | ||
| ┌─────────────────────────────────────────────────────────────┐ | ||
| │ TAB │ | ||
| │ tab:basedir = "/home/user/project" │ | ||
| │ tab:basedirlock = false │ | ||
| │ │ | ||
| │ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ | ||
| │ │ Terminal 1 │ │ Terminal 2 │ │ File View │ │ | ||
| │ │ cmd:cwd = ... │ │ cmd:cwd = ... │ │ file = ... │ │ | ||
| │ │ (inherits tab) │ │ (inherits tab) │ │ (inherits) │ │ | ||
| │ └─────────────────┘ └─────────────────┘ └──────────────┘ │ | ||
| └─────────────────────────────────────────────────────────────┘ | ||
| ``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a language to the fenced code block.
Markdownlint MD040 flags the unlabeled fence. Add a language (e.g., text) to avoid lint failures and improve readability.
✅ Proposed fix
-```
+```text
┌─────────────────────────────────────────────────────────────┐
│ TAB │
│ tab:basedir = "/home/user/project" │
│ tab:basedirlock = false │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │
│ │ Terminal 1 │ │ Terminal 2 │ │ File View │ │
│ │ cmd:cwd = ... │ │ cmd:cwd = ... │ │ file = ... │ │
│ │ (inherits tab) │ │ (inherits tab) │ │ (inherits) │ │
│ └─────────────────┘ └─────────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
-```
+```📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ``` | |
| ┌─────────────────────────────────────────────────────────────┐ | |
| │ TAB │ | |
| │ tab:basedir = "/home/user/project" │ | |
| │ tab:basedirlock = false │ | |
| │ │ | |
| │ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ | |
| │ │ Terminal 1 │ │ Terminal 2 │ │ File View │ │ | |
| │ │ cmd:cwd = ... │ │ cmd:cwd = ... │ │ file = ... │ │ | |
| │ │ (inherits tab) │ │ (inherits tab) │ │ (inherits) │ │ | |
| │ └─────────────────┘ └─────────────────┘ └──────────────┘ │ | |
| └─────────────────────────────────────────────────────────────┘ | |
| ``` |
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)
330-330: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
In `@CLAUDE.md` around lines 330 - 342, The fenced ASCII-art code block in
CLAUDE.md is missing a language label which triggers markdownlint MD040; update
the opening fence for that ASCII-art block to include a language (e.g., add
"text" after the triple backticks) so the block becomes ```text, preserving the
exact block content (the TAB/Terminal/File View ASCII diagram) and the closing
triple backticks remain unchanged.
| const allowedProperties = | ||
| options.properties?.filter((p) => ["openDirectory", "showHiddenFiles"].includes(p)) || | ||
| ["openDirectory"]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Empty properties array bypasses the default.
If options.properties is an empty array [], the filter returns [], which is truthy, so the || ["openDirectory"] fallback won't trigger. This could result in a dialog with no selection mode.
🐛 Proposed fix
- const allowedProperties =
- options.properties?.filter((p) => ["openDirectory", "showHiddenFiles"].includes(p)) ||
- ["openDirectory"];
+ const filteredProperties =
+ options.properties?.filter((p) => ["openDirectory", "showHiddenFiles"].includes(p));
+ const allowedProperties =
+ filteredProperties?.length > 0 ? filteredProperties : ["openDirectory"];📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const allowedProperties = | |
| options.properties?.filter((p) => ["openDirectory", "showHiddenFiles"].includes(p)) || | |
| ["openDirectory"]; | |
| const filteredProperties = | |
| options.properties?.filter((p) => ["openDirectory", "showHiddenFiles"].includes(p)); | |
| const allowedProperties = | |
| filteredProperties?.length > 0 ? filteredProperties : ["openDirectory"]; |
🤖 Prompt for AI Agents
In `@emain/emain-ipc.ts` around lines 469 - 471, The current assignment to
allowedProperties uses options.properties?.filter(...) || ["openDirectory"]
which treats an empty filtered array as truthy and bypasses the default; update
the logic around allowedProperties (in emain-ipc.ts) to treat an empty result as
missing by checking options.properties and/or the filtered array length and
falling back to ["openDirectory"] when the input was undefined or the filter
yields an empty array (ensure you reference options.properties and the
allowedProperties variable and apply the filter only when there are items,
otherwise set the default).
| async function validateActiveTabBasedir(tabId: string): Promise<void> { | ||
| if (!tabId) return; | ||
|
|
||
| const tabModel = getTabModelByTabId(tabId); | ||
| const lastValidationTime = globalStore.get(tabModel.lastValidationTimeAtom); | ||
| const now = Date.now(); | ||
|
|
||
| // Debounce: skip if validated recently | ||
| if (now - lastValidationTime < DEBOUNCE_INTERVAL_MS) { | ||
| return; | ||
| } | ||
|
|
||
| // Get tab data | ||
| const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId)); | ||
| const tabData = globalStore.get(tabAtom); | ||
|
|
||
| if (!tabData) { | ||
| return; | ||
| } | ||
|
|
||
| const basedir = tabData.meta?.["tab:basedir"]; | ||
|
|
||
| // Skip validation if no basedir set | ||
| if (!basedir || basedir.trim() === "") { | ||
| return; | ||
| } | ||
|
|
||
| // Update validation state to pending | ||
| globalStore.set(tabModel.basedirValidationAtom, "pending"); | ||
| globalStore.set(tabModel.lastValidationTimeAtom, now); | ||
|
|
||
| // Perform validation | ||
| const result = await validateTabBasedir(tabId, basedir); | ||
|
|
||
| if (result.valid) { | ||
| // Update validation state to valid | ||
| globalStore.set(tabModel.basedirValidationAtom, "valid"); | ||
| } else { | ||
| // Update validation state to invalid | ||
| globalStore.set(tabModel.basedirValidationAtom, "invalid"); | ||
|
|
||
| // Handle stale basedir (will clear and notify) | ||
| if (result.reason) { | ||
| const { handleStaleBasedir } = await import("./tab-basedir-validator"); | ||
| await handleStaleBasedir(tabId, basedir, result.reason); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard against missing tab models and failed validations.
getTabModelByTabId may return null and validateTabBasedir can throw, which would cause unhandled rejections and leave the validation state stuck in "pending". Add a null guard and a try/catch that sets a safe state on error.
🛠️ Proposed fix: null guard + error handling
- const tabModel = getTabModelByTabId(tabId);
+ const tabModel = getTabModelByTabId(tabId);
+ if (!tabModel) {
+ return;
+ }
const lastValidationTime = globalStore.get(tabModel.lastValidationTimeAtom);
const now = Date.now();
@@
- const result = await validateTabBasedir(tabId, basedir);
-
- if (result.valid) {
- // Update validation state to valid
- globalStore.set(tabModel.basedirValidationAtom, "valid");
- } else {
- // Update validation state to invalid
- globalStore.set(tabModel.basedirValidationAtom, "invalid");
-
- // Handle stale basedir (will clear and notify)
- if (result.reason) {
- const { handleStaleBasedir } = await import("./tab-basedir-validator");
- await handleStaleBasedir(tabId, basedir, result.reason);
- }
- }
+ try {
+ const result = await validateTabBasedir(tabId, basedir);
+ if (result.valid) {
+ globalStore.set(tabModel.basedirValidationAtom, "valid");
+ } else {
+ globalStore.set(tabModel.basedirValidationAtom, "invalid");
+ if (result.reason) {
+ const { handleStaleBasedir } = await import("./tab-basedir-validator");
+ await handleStaleBasedir(tabId, basedir, result.reason);
+ }
+ }
+ } catch (error) {
+ console.error("[tab-basedir-validation] Validation failed:", error);
+ globalStore.set(tabModel.basedirValidationAtom, "invalid");
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function validateActiveTabBasedir(tabId: string): Promise<void> { | |
| if (!tabId) return; | |
| const tabModel = getTabModelByTabId(tabId); | |
| const lastValidationTime = globalStore.get(tabModel.lastValidationTimeAtom); | |
| const now = Date.now(); | |
| // Debounce: skip if validated recently | |
| if (now - lastValidationTime < DEBOUNCE_INTERVAL_MS) { | |
| return; | |
| } | |
| // Get tab data | |
| const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId)); | |
| const tabData = globalStore.get(tabAtom); | |
| if (!tabData) { | |
| return; | |
| } | |
| const basedir = tabData.meta?.["tab:basedir"]; | |
| // Skip validation if no basedir set | |
| if (!basedir || basedir.trim() === "") { | |
| return; | |
| } | |
| // Update validation state to pending | |
| globalStore.set(tabModel.basedirValidationAtom, "pending"); | |
| globalStore.set(tabModel.lastValidationTimeAtom, now); | |
| // Perform validation | |
| const result = await validateTabBasedir(tabId, basedir); | |
| if (result.valid) { | |
| // Update validation state to valid | |
| globalStore.set(tabModel.basedirValidationAtom, "valid"); | |
| } else { | |
| // Update validation state to invalid | |
| globalStore.set(tabModel.basedirValidationAtom, "invalid"); | |
| // Handle stale basedir (will clear and notify) | |
| if (result.reason) { | |
| const { handleStaleBasedir } = await import("./tab-basedir-validator"); | |
| await handleStaleBasedir(tabId, basedir, result.reason); | |
| } | |
| async function validateActiveTabBasedir(tabId: string): Promise<void> { | |
| if (!tabId) return; | |
| const tabModel = getTabModelByTabId(tabId); | |
| if (!tabModel) { | |
| return; | |
| } | |
| const lastValidationTime = globalStore.get(tabModel.lastValidationTimeAtom); | |
| const now = Date.now(); | |
| // Debounce: skip if validated recently | |
| if (now - lastValidationTime < DEBOUNCE_INTERVAL_MS) { | |
| return; | |
| } | |
| // Get tab data | |
| const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId)); | |
| const tabData = globalStore.get(tabAtom); | |
| if (!tabData) { | |
| return; | |
| } | |
| const basedir = tabData.meta?.["tab:basedir"]; | |
| // Skip validation if no basedir set | |
| if (!basedir || basedir.trim() === "") { | |
| return; | |
| } | |
| // Update validation state to pending | |
| globalStore.set(tabModel.basedirValidationAtom, "pending"); | |
| globalStore.set(tabModel.lastValidationTimeAtom, now); | |
| // Perform validation | |
| try { | |
| const result = await validateTabBasedir(tabId, basedir); | |
| if (result.valid) { | |
| globalStore.set(tabModel.basedirValidationAtom, "valid"); | |
| } else { | |
| globalStore.set(tabModel.basedirValidationAtom, "invalid"); | |
| if (result.reason) { | |
| const { handleStaleBasedir } = await import("./tab-basedir-validator"); | |
| await handleStaleBasedir(tabId, basedir, result.reason); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("[tab-basedir-validation] Validation failed:", error); | |
| globalStore.set(tabModel.basedirValidationAtom, "invalid"); | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@frontend/app/store/tab-basedir-validation-hook.ts` around lines 13 - 58,
validateActiveTabBasedir currently assumes getTabModelByTabId returns a model
and that validateTabBasedir never throws, which can leave the atom stuck in
"pending"; add a null guard after calling getTabModelByTabId to return early if
tabModel is falsy, and wrap the call to validateTabBasedir in a try/catch; on
any caught error set globalStore.set(tabModel.basedirValidationAtom, "invalid")
(or a designated "error" state), update
globalStore.set(tabModel.lastValidationTimeAtom, now) to avoid debounce
starvation, and optionally import/log the error before returning; keep existing
successful and stale-basedir paths (handleStaleBasedir) intact.
| // - server:/path (NFS) | ||
| // - /net/server/path (automounter) | ||
| if (/^[^\/\\]+:\//.test(path) || path.startsWith("/net/")) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NFS regex may incorrectly match Windows drive paths.
The regex /^[^\/\\]+:\// intended for NFS paths like server:/path will also match Windows paths like C:/Users. The pattern C matches [^\/\\]+, and :/ matches :\/.
Consider adding an exclusion for single-letter drive prefixes.
Suggested fix
// NFS paths (common patterns)
// - server:/path (NFS)
// - /net/server/path (automounter)
- if (/^[^\/\\]+:\//.test(path) || path.startsWith("/net/")) {
+ // Exclude Windows drive letters (single letter followed by :/)
+ if ((/^[^\/\\]+:\//.test(path) && !/^[a-zA-Z]:\//.test(path)) || path.startsWith("/net/")) {
return true;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // - server:/path (NFS) | |
| // - /net/server/path (automounter) | |
| if (/^[^\/\\]+:\//.test(path) || path.startsWith("/net/")) { | |
| return true; | |
| } | |
| // - server:/path (NFS) | |
| // - /net/server/path (automounter) | |
| // Exclude Windows drive letters (single letter followed by :/) | |
| if ((/^[^\/\\]+:\//.test(path) && !/^[a-zA-Z]:\//.test(path)) || path.startsWith("/net/")) { | |
| return true; | |
| } |
🤖 Prompt for AI Agents
In `@frontend/app/store/tab-basedir-validator.ts` around lines 88 - 92, The
NFS-detection regex in the tab-basedir-validator (the if that currently uses
/^[^\/\\]+:\//) also matches Windows single-letter drives like "C:/"; update
that condition to exclude single-letter drive prefixes by replacing the regex
with a negative-lookahead form such as /^(?![A-Za-z]:\/)[^\/\\]+:\// (keep the
existing logical OR with path.startsWith("/net/") and return true behavior in
the same block).
| clearFinishedUnread(): void { | ||
| globalStore.set(this.finishedUnreadAtom, false); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
clearFinishedUnread should also trigger recomputation.
Similar to setFinishedUnread, clearing the finished state should update the terminal status atom.
Suggested fix
clearFinishedUnread(): void {
globalStore.set(this.finishedUnreadAtom, false);
+ this.recomputeTerminalStatus();
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| clearFinishedUnread(): void { | |
| globalStore.set(this.finishedUnreadAtom, false); | |
| } | |
| clearFinishedUnread(): void { | |
| globalStore.set(this.finishedUnreadAtom, false); | |
| this.recomputeTerminalStatus(); | |
| } |
🤖 Prompt for AI Agents
In `@frontend/app/store/tab-model.ts` around lines 77 - 79, clearFinishedUnread
currently only sets finishedUnreadAtom to false but doesn't trigger the terminal
status recomputation; update clearFinishedUnread to also call globalStore.set on
the terminalStatusAtom (the same way setFinishedUnread does) so the terminal
status is recalculated — i.e., after setting this.finishedUnreadAtom to false,
invoke globalStore.set(this.terminalStatusAtom, ...) to trigger recompute of
terminal status.
| func (svc *ObjectService) UpdateObjectMetaWithVersion(uiContext waveobj.UIContext, orefStr string, meta waveobj.MetaMapType, expectedVersion int) (waveobj.UpdatesRtnType, error) { | ||
| ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) | ||
| defer cancelFn() | ||
| ctx = waveobj.ContextWithUpdates(ctx) | ||
| oref, err := parseORef(orefStr) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error parsing object reference: %w", err) | ||
| } | ||
| err = wstore.UpdateObjectMetaWithVersion(ctx, *oref, meta, expectedVersion, false) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error updating %q meta: %w", orefStr, err) | ||
| } | ||
| return waveobj.ContextGetUpdatesRtn(ctx), nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing metadata validation.
UpdateObjectMeta validates metadata before persistence (lines 146-149), but UpdateObjectMetaWithVersion does not. This inconsistency could allow invalid metadata to bypass validation when using the versioned update path.
🐛 Suggested fix
func (svc *ObjectService) UpdateObjectMetaWithVersion(uiContext waveobj.UIContext, orefStr string, meta waveobj.MetaMapType, expectedVersion int) (waveobj.UpdatesRtnType, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx)
oref, err := parseORef(orefStr)
if err != nil {
return nil, fmt.Errorf("error parsing object reference: %w", err)
}
+ // Validate metadata before persistence
+ if err := waveobj.ValidateMetadata(*oref, meta); err != nil {
+ return nil, fmt.Errorf("metadata validation failed: %w", err)
+ }
err = wstore.UpdateObjectMetaWithVersion(ctx, *oref, meta, expectedVersion, false)🤖 Prompt for AI Agents
In `@pkg/service/objectservice/objectservice.go` around lines 163 - 176,
UpdateObjectMetaWithVersion is missing the metadata validation that
UpdateObjectMeta performs, so add the same validation step before persisting:
invoke the same validation function or logic used in UpdateObjectMeta (e.g., the
meta validation helper such as validateMeta or waveobj.ValidateMeta used
earlier) on the meta parameter and return an error if validation fails, then
proceed to call wstore.UpdateObjectMetaWithVersion; ensure you reference
UpdateObjectMetaWithVersion and the validation helper so the behavior matches
the non-versioned path.
| func (svc *ObjectService) UpdateObjectMetaIfNotLocked(uiContext waveobj.UIContext, orefStr string, meta waveobj.MetaMapType, lockKey string, expectedVersion int) (waveobj.UpdatesRtnType, error) { | ||
| ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) | ||
| defer cancelFn() | ||
| ctx = waveobj.ContextWithUpdates(ctx) | ||
| oref, err := parseORef(orefStr) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error parsing object reference: %w", err) | ||
| } | ||
| err = wstore.UpdateObjectMetaIfNotLocked(ctx, *oref, meta, lockKey, expectedVersion) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error updating %q meta: %w", orefStr, err) | ||
| } | ||
| return waveobj.ContextGetUpdatesRtn(ctx), nil | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing metadata validation (same issue).
UpdateObjectMetaIfNotLocked also lacks metadata validation, creating another path to bypass validation.
🐛 Suggested fix
func (svc *ObjectService) UpdateObjectMetaIfNotLocked(uiContext waveobj.UIContext, orefStr string, meta waveobj.MetaMapType, lockKey string, expectedVersion int) (waveobj.UpdatesRtnType, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx)
oref, err := parseORef(orefStr)
if err != nil {
return nil, fmt.Errorf("error parsing object reference: %w", err)
}
+ // Validate metadata before persistence
+ if err := waveobj.ValidateMetadata(*oref, meta); err != nil {
+ return nil, fmt.Errorf("metadata validation failed: %w", err)
+ }
err = wstore.UpdateObjectMetaIfNotLocked(ctx, *oref, meta, lockKey, expectedVersion)🤖 Prompt for AI Agents
In `@pkg/service/objectservice/objectservice.go` around lines 184 - 197,
UpdateObjectMetaIfNotLocked currently skips metadata validation and directly
calls wstore.UpdateObjectMetaIfNotLocked; before calling the store, validate the
incoming meta (waveobj.MetaMapType) the same way other update paths do—e.g.,
call the existing validation helper (or add one named validateMeta(meta
waveobj.MetaMapType) if missing) and return an error on validation failure; keep
the rest of the function (parseORef, context setup, and call to
wstore.UpdateObjectMetaIfNotLocked) unchanged so only valid metadata reaches the
store.
| # Hook into PSReadLine to detect when commands are executed | ||
| # This is called just before a command is accepted and executed | ||
| if (Get-Module PSReadLine) { | ||
| # Save any existing AcceptLine handler | ||
| $existingHandler = (Get-PSReadLineKeyHandler -Chord Enter | Where-Object { $_.Function -eq 'AcceptLine' }) | ||
| # Create a wrapper that sends command notification before executing | ||
| Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock { | ||
| $line = $null | ||
| $cursor = $null | ||
| [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) | ||
| # Send command started notification | ||
| if (-not [string]::IsNullOrWhiteSpace($line)) { | ||
| _waveterm_si_send_command -Command $line | ||
| } | ||
| # Call the original AcceptLine function | ||
| [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
find . -name "pwsh_wavepwsh.sh" -type fRepository: wavetermdev/waveterm
Length of output: 118
🏁 Script executed:
cat -n pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh | sed -n '90,130p'Repository: wavetermdev/waveterm
Length of output: 1620
🌐 Web query:
PSReadLine Get-PSReadLineKeyHandler Chord Enter PowerShell API documentation
💡 Result:
Here are the official/authoritative references and how "Enter" / chord usage is represented:
- PSReadLine repo (overview, examples and key-binding system). Useful for implementation details and examples. [1]
- Set-PSReadLineKeyHandler documentation — describes the -Chord parameter (string[]), syntax for chords (e.g. "Ctrl+X,Ctrl+L") and notes about case-sensitivity. Use this to bind keys/chords to functions or scriptblocks. [2]
- PSReadLine key definitions show that Enter is represented via ConsoleKey.Enter / Keys.Enter (PSKeyInfo.From(ConsoleKey.Enter)) when PSReadLine maps Console keys. So use "Enter" (or the ConsoleKey name) in chords. [3]
Quick example:
- Bind Enter (single key) — use Chord "Enter".
- Bind a sequence or modifier chord — e.g. "Ctrl+X,Ctrl+L" or "Ctrl+Enter" (if supported by the host).
Sources:
[1] PowerShell/PSReadLine (GitHub).
[2] Set-PSReadlineKeyHandler cmdlet documentation (PSReadLine module).
[3] PSReadLine key-binding system / Keys mapping (PSReadLine code/docs).
Remove captured handler variable or invoke it to preserve user customizations.
The code captures $existingHandler at line 103 but never uses it. Calling Set-PSReadLineKeyHandler immediately after overwrites any custom Enter key binding the user may have configured. This breaks existing customizations that depend on Enter behavior.
Either remove the unused $existingHandler variable if this behavior is intentional, or invoke it (via its .ScriptBlock property if present) within the new handler before calling AcceptLine().
🤖 Prompt for AI Agents
In `@pkg/util/shellutil/shellintegration/pwsh_wavepwsh.sh` around lines 99 - 118,
The captured $existingHandler is never used, which overwrites any user Enter key
customization; update the PSReadLine wrapper installed via
Set-PSReadLineKeyHandler to preserve prior behavior: detect if $existingHandler
(from Get-PSReadLineKeyHandler -Chord Enter) has a .ScriptBlock and invoke it
(passing through the same buffer/cursor context) inside the new ScriptBlock
either before or after calling
[Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine(), or if you intended to
discard prior handlers remove the unused $existingHandler variable; modify the
wrapper around _waveterm_si_send_command so it calls the original handler's
ScriptBlock when present to retain user customizations.
| // For Windows UNC paths (\\server\share\path) | ||
| if runtime.GOOS == "windows" && strings.HasPrefix(path, "\\\\") { | ||
| // UNC paths should not traverse above the share | ||
| parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) | ||
| if len(parts) >= 4 { // \\server\share\... | ||
| sharePath := filepath.Join(parts[0], parts[1], parts[2], parts[3]) | ||
| if !strings.HasPrefix(filepath.Clean(path), sharePath) { | ||
| return fmt.Errorf("path traversal detected in UNC path") | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let me read the actual file to see the context around lines 205-215
cat -n pkg/waveobj/validators.go | sed -n '190,230p'Repository: wavetermdev/waveterm
Length of output: 1577
🏁 Script executed:
# Now let's verify the actual Go splitting behavior with a simple test
# Go strings.Split behavior for UNC paths
cat > /tmp/test_split.go << 'EOF'
package main
import (
"fmt"
"strings"
)
func main() {
// Test UNC path splitting
path := "\\\\server\\share\\folder"
parts := strings.Split(path, "\\")
fmt.Printf("Path: %q\n", path)
fmt.Printf("Parts: %v\n", parts)
fmt.Printf("Length: %d\n", len(parts))
for i, p := range parts {
fmt.Printf("parts[%d]: %q\n", i, p)
}
// What would filepath.Join do with parts[0:4]?
if len(parts) >= 4 {
fmt.Printf("\nJoining parts[0:4]: %v\n", parts[0:4])
joined := strings.Join(parts[0:4], "\\")
fmt.Printf("Result: %q\n", joined)
}
}
EOF
go run /tmp/test_split.goRepository: wavetermdev/waveterm
Length of output: 286
🏁 Script executed:
cat > /tmp/test_traversal.go << 'EOF'
package main
import (
"fmt"
"path/filepath"
"strings"
)
func main() {
// Test various UNC paths
testPaths := []string{
"\\\\server\\share\\folder",
"\\\\server\\share\\..\\evil",
"\\\\server\\share\\folder\\..\\..\\evil",
"\\\\server\\share\\",
"\\\\server\\share",
}
for _, path := range testPaths {
fmt.Printf("\n--- Testing: %q ---\n", path)
// Simulate the code logic
parts := strings.Split(filepath.Clean(path), string(filepath.Separator))
fmt.Printf("Parts: %v (len=%d)\n", parts, len(parts))
if len(parts) >= 4 {
sharePath := filepath.Join(parts[0], parts[1], parts[2], parts[3])
fmt.Printf("sharePath: %q\n", sharePath)
cleanedPath := filepath.Clean(path)
fmt.Printf("filepath.Clean(path): %q\n", cleanedPath)
hasPrefix := strings.HasPrefix(cleanedPath, sharePath)
fmt.Printf("HasPrefix: %v\n", hasPrefix)
if !hasPrefix {
fmt.Printf("WOULD REJECT (traversal detected)\n")
} else {
fmt.Printf("WOULD ACCEPT\n")
}
} else {
fmt.Printf("WOULD ACCEPT (len < 4)\n")
}
}
}
EOF
go run /tmp/test_traversal.goRepository: wavetermdev/waveterm
Length of output: 604
🏁 Script executed:
cat > /tmp/test_unc_clean.go << 'EOF'
package main
import (
"fmt"
"path/filepath"
"strings"
)
func main() {
// Test what filepath.Clean does to UNC paths
// Note: These tests show Go's path/filepath behavior
testCases := []string{
`\\server\share\folder`,
`\\server\share\folder\..`,
`\\server\share\folder\..\..`,
`\\server\share\..`,
}
for _, testPath := range testCases {
fmt.Printf("\nOriginal path: %q\n", testPath)
cleaned := filepath.Clean(testPath)
fmt.Printf("After filepath.Clean: %q\n", cleaned)
// Simulate the code's behavior
parts := strings.Split(cleaned, string(filepath.Separator))
fmt.Printf("filepath.Separator on this system: %q\n", string(filepath.Separator))
fmt.Printf("Split result: %v (len=%d)\n", parts, len(parts))
}
}
EOF
go run /tmp/test_unc_clean.goRepository: wavetermdev/waveterm
Length of output: 800
🏁 Script executed:
# Also check Go documentation behavior - test with explicit forward slashes
cat > /tmp/test_unc_forward.go << 'EOF'
package main
import (
"fmt"
"strings"
)
func main() {
// Simulate Windows UNC path (Go uses forward slashes internally on all platforms)
path := `\\server\share\folder`
fmt.Printf("Path: %q\n", path)
fmt.Printf("\nSplitting by backslash:\n")
parts := strings.Split(path, "\\")
fmt.Printf("Result: %v\n", parts)
for i, p := range parts {
fmt.Printf(" [%d] = %q\n", i, p)
}
// What the fixed code suggests
fmt.Printf("\nIf we construct: %q + %q + %q + %q\n", parts[2], "\\", parts[3], "\\")
}
EOF
go run /tmp/test_unc_forward.goRepository: wavetermdev/waveterm
Length of output: 277
🏁 Script executed:
# Check if there are any tests in the repository related to UNC path handling
rg -i "unc|traversal" pkg/waveobj/validators.go -B 5 -A 5Repository: wavetermdev/waveterm
Length of output: 15140
🏁 Script executed:
# Look for the full function context to understand the check better
cat -n pkg/waveobj/validators.go | sed -n '160,230p'Repository: wavetermdev/waveterm
Length of output: 2625
🏁 Script executed:
# Search for any existing tests for this validation function
fd -type f -name "*test*.go" | xargs rg "checkPathTraversal" -lRepository: wavetermdev/waveterm
Length of output: 277
🏁 Script executed:
find pkg/waveobj -name "*test*" -type fRepository: wavetermdev/waveterm
Length of output: 46
🏁 Script executed:
# Search for references to checkPathTraversal in tests
rg "checkPathTraversal|ValidatePath" --type go -lRepository: wavetermdev/waveterm
Length of output: 108
Windows UNC path traversal check is confusing and potentially ineffective.
When splitting \\server\share\path by the path separator on Windows, parts[0] and parts[1] are empty strings, so filepath.Join(parts[0], parts[1], parts[2], parts[3]) constructs \\server\share correctly. However, the logic at lines 209-214 is problematic: filepath.Clean() normalizes away ".." during cleaning, so paths like \\server\share\.. get cleaned before the prefix check runs. The condition if !strings.HasPrefix(filepath.Clean(path), sharePath) then becomes ineffective—the prefix check can't reliably detect UNC traversal because the traversal has already been removed by filepath.Clean(). Additionally, the final ".." check at lines 217-225 won't catch UNC traversal attempts since ".." is already removed by filepath.Clean() before that check runs.
The UNC check should validate the traversal before normalizing, or use a more explicit method to detect escape attempts from the share root.
🤖 Prompt for AI Agents
In `@pkg/waveobj/validators.go` around lines 205 - 215, The UNC path check
currently cleans the path before testing for traversal which removes ".." and
defeats detection; update the logic in validators.go (the Windows UNC branch
that uses runtime.GOOS, strings.HasPrefix, filepath.Clean, parts and sharePath)
to inspect the raw, uncleaned path segments first (split the original path on
filepath.Separator or backslashes), detect any ".." segments that would escape
the "\\server\share" root, and only then proceed to clean/normalize;
specifically, build the UNC root from the first four raw segments and fail if
any ".." would reduce the segment count below the share root or if any segment
before the share root is "..", ensuring the check runs before calling
filepath.Clean or relying on strings.HasPrefix.
| { | ||
| "$schema": "https://json-schema.org/draft/2020-12/schema", | ||
| "$id": "wave://schema/tabvarspresets.json", | ||
| "title": "Tab Variables Presets", | ||
| "description": "Schema for tab:basedir and related tab variable presets", | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "patternProperties": { | ||
| "^tabvar@[a-zA-Z0-9_-]+$": { | ||
| "$ref": "#/$defs/TabVarPreset" | ||
| } | ||
| }, | ||
| "$defs": { | ||
| "TabVarPreset": { | ||
| "type": "object", | ||
| "additionalProperties": false, | ||
| "properties": { | ||
| "tab:basedir": { | ||
| "type": "string", | ||
| "maxLength": 4096, | ||
| "pattern": "^[^\\x00]+$", | ||
| "description": "Base directory for the tab context" | ||
| }, | ||
| "tab:basedirlock": { | ||
| "type": "boolean", | ||
| "description": "Lock basedir to prevent smart auto-detection" | ||
| }, | ||
| "display:name": { | ||
| "type": "string", | ||
| "maxLength": 256, | ||
| "description": "Display name shown in context menu" | ||
| }, | ||
| "display:order": { | ||
| "type": "number", | ||
| "description": "Order in context menu (lower = higher)" | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Schema/validator mismatch for tab:basedir.
Runtime validator allows null (and empty string), but schema rejects both. Aligning avoids confusing UX (Monaco errors vs save success).
🧩 Example schema alignment (allow null)
- "tab:basedir": {
- "type": "string",
+ "tab:basedir": {
+ "type": ["string", "null"],
"maxLength": 4096,
"pattern": "^[^\\x00]+$",
"description": "Base directory for the tab context"
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| { | |
| "$schema": "https://json-schema.org/draft/2020-12/schema", | |
| "$id": "wave://schema/tabvarspresets.json", | |
| "title": "Tab Variables Presets", | |
| "description": "Schema for tab:basedir and related tab variable presets", | |
| "type": "object", | |
| "additionalProperties": false, | |
| "patternProperties": { | |
| "^tabvar@[a-zA-Z0-9_-]+$": { | |
| "$ref": "#/$defs/TabVarPreset" | |
| } | |
| }, | |
| "$defs": { | |
| "TabVarPreset": { | |
| "type": "object", | |
| "additionalProperties": false, | |
| "properties": { | |
| "tab:basedir": { | |
| "type": "string", | |
| "maxLength": 4096, | |
| "pattern": "^[^\\x00]+$", | |
| "description": "Base directory for the tab context" | |
| }, | |
| "tab:basedirlock": { | |
| "type": "boolean", | |
| "description": "Lock basedir to prevent smart auto-detection" | |
| }, | |
| "display:name": { | |
| "type": "string", | |
| "maxLength": 256, | |
| "description": "Display name shown in context menu" | |
| }, | |
| "display:order": { | |
| "type": "number", | |
| "description": "Order in context menu (lower = higher)" | |
| } | |
| } | |
| } | |
| } | |
| { | |
| "$schema": "https://json-schema.org/draft/2020-12/schema", | |
| "$id": "wave://schema/tabvarspresets.json", | |
| "title": "Tab Variables Presets", | |
| "description": "Schema for tab:basedir and related tab variable presets", | |
| "type": "object", | |
| "additionalProperties": false, | |
| "patternProperties": { | |
| "^tabvar@[a-zA-Z0-9_-]+$": { | |
| "$ref": "#/$defs/TabVarPreset" | |
| } | |
| }, | |
| "$defs": { | |
| "TabVarPreset": { | |
| "type": "object", | |
| "additionalProperties": false, | |
| "properties": { | |
| "tab:basedir": { | |
| "type": ["string", "null"], | |
| "maxLength": 4096, | |
| "pattern": "^[^\\x00]+$", | |
| "description": "Base directory for the tab context" | |
| }, | |
| "tab:basedirlock": { | |
| "type": "boolean", | |
| "description": "Lock basedir to prevent smart auto-detection" | |
| }, | |
| "display:name": { | |
| "type": "string", | |
| "maxLength": 256, | |
| "description": "Display name shown in context menu" | |
| }, | |
| "display:order": { | |
| "type": "number", | |
| "description": "Order in context menu (lower = higher)" | |
| } | |
| } | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@schema/tabvarspresets.json` around lines 1 - 39, The schema rejects null and
empty string for the "tab:basedir" property while the runtime validator accepts
them; update the TabVarPreset definition so "tab:basedir" accepts null and empty
strings by changing its type from "string" to ["string","null"] and relaxing the
pattern to allow an empty value (e.g., replace the "^[^\\x00]+$" pattern with
one that permits zero-length like "^[^\\x00]*$" or remove the pattern), keeping
maxLength and the description intact so schema and runtime behavior align.
## Tab Base Directory Feature - Per-tab base directory context (tab:basedir, tab:basedirlock metadata) - Smart auto-detection from terminal OSC 7 sequences - All terminals and widgets inherit the tab's base directory - Tab context menu for configuration - Stale path detection and recovery after restart ## Security & Validation - Path validation for OSC 7 terminal escape sequences - Backend metadata validation to prevent injection attacks - Optimistic locking for metadata updates (race condition fix) - Preset metadata validation ## VS Code Style Tab Redesign - Tab backgrounds use relative white overlays (active/hover/inactive) - Tab stripe moved from left to top horizontal (manual color only) - Status indicated via text color (running=blue, finished=green, stopped=red) - Breadcrumb bar below tabs showing active tab's base directory - App menu moved from tab bar to breadcrumb bar - Transparent button styling with subtle hover effects ## Other Improvements - Tab color customization via context menu - Reactive tab status icons using Jotai atoms - Reusable preset menu builder - showOpenDialog API for file picker dialogs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1a243dd to
5622d24
Compare
Summary
This PR consolidates multiple improvements to the tab system:
Tab Base Directory Feature
tab:basedir,tab:basedirlockmetadata)Security & Validation
VS Code Style Tab Redesign
Other Improvements
Relationship to Other PRs
Test plan
cdcommands🤖 Generated with Claude Code